結論先說:事件契約定義好只是起點;真正決定值班工程師能不能在深夜迅速定位問題的,是欄位有沒有放對位置、告警門檻有沒有踩對消耗速度、以及 incident 發生時能不能照這組訊號縮小排查範圍。
承接上文:Day 18(上)把一次 /ask 拆成 system SLI 與 AI SLI 兩層訊號,定義了 request_received、first_token 等九個時間點,並用 AskEvent/AskTiming 兩個 dataclass 把契約寫死,也談了 P95 的意義與 token 成本這個常被忽略的維度。本篇接著把契約落地:怎麼在 FastAPI 記下這些事件、哪些欄位不能塞進 Prometheus label、哪些訊號該告警,以及幾個真實事故如何驗證這套判讀框架。
接下來的程式是讀者可自行放進 Day 18 的 DIY 或既有服務的最小版本。
本文沒有建立、安裝、啟動或驗證任何 DIY;下面每一段程式碼旁邊,都會多寫一段「這在驗證前面哪個論點」與「跑起來預期看到什麼、容易踩到什麼坑」,讓不打算真的執行 DIY 的讀者,也能看懂這段實作在做什麼、為什麼要這樣寫。
開始前,讀者需要一個可啟動的 FastAPI application,以及一個不含敏感 prompt、完整回答或使用者識別資料的 structured log sink。
AskEvent一個常見的直覺是先把第⑥節那份完整的 AskEvent 契約寫出來,直接套進 handler。但這裡刻意先從最外層、最簡單的 HTTP middleware 開始,呼應第①節「System SLI 與 AI SLI 分開量」的立場:middleware 量的是最不會出錯的邊界——請求進來、回應出去,即使 handler 內部的 workflow 邏輯還沒寫,也已經能回答「這個 API 通不通、多慢」。先做出最容易做對的一層,再逐步往內補細節事件,比一次到位更不容易半途而廢。
FastAPI 官方的 HTTP middleware 會在 request 進入 path operation 前與 response 回傳前執行;適合量 API request 邊界。FastAPI Middleware 文件
在 app/main.py 加入最小 middleware:
import json
import logging
from time import perf_counter
from fastapi import FastAPI, Request
app = FastAPI()
logger = logging.getLogger("ai_sli")
@app.middleware("http")
async def record_request_boundary(request: Request, call_next):
started_at = perf_counter()
request_id = request.headers.get("x-request-id", "generated-in-handler")
try:
response = await call_next(request)
except Exception:
logger.exception(
"ask_request_failed",
extra={
"request_id": request_id,
"route": request.url.path,
"end_to_end_ms": round((perf_counter() - started_at) * 1000, 2),
},
)
raise
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
logger.info(
"ask_request_finished",
extra={
"request_id": request_id,
"route": request.url.path,
"http_status": response.status_code,
"end_to_end_ms": elapsed_ms,
},
)
response.headers["x-request-id"] = request_id
return response
這段 middleware 量得到 HTTP request 的外框。
它量不到 TTFT,也不知道 retrieval 是否成功。
別急著批評它不夠;那正好說明不同的事件應在不同層記錄。
HTTP middleware 不該猜測 handler 內部發生什麼事。
這段驗證了前面哪個論點:它示範第⑤節「先定義一次回答的邊界」裡最外層的那一段——request_received 與 response_sent。它同時也是一個活的反例:故意只做這一層,讓讀者實際感受到只有這層資訊時能回答什麼(API 通不通、多慢),不能回答什麼(慢在哪一段)。
實際跑起來預期看到什麼:對任何一個已存在的 endpoint 送出請求,log 應該會多一行 ask_request_finished,帶著 request_id、route、http_status、end_to_end_ms。把 endpoint 換成一個人為加了 await asyncio.sleep(2) 的假 handler,end_to_end_ms 應該穩定落在 2000ms 附近,驗證這個 middleware 量到的確實是 handler 實際花的時間。
容易踩到的坑:第一,若用 uvicorn --workers N 啟動多個 process,logger 沒有跨 process 共享狀態——log 各自輸出沒問題,但若想在 middleware 裡做「進行中請求計數」這類 in-memory 統計,每個 worker 會各自維護一份、數字對不起來,得靠 Redis 或 Prometheus 的 multiprocess 模式處理。第二,若 call_next(request) 內部的例外被更內層的 exception handler 攔截並轉成 200,這裡記到的 http_status 會是轉換後的狀態碼,不是原始例外——要區分「優雅降級成 200」與「真的正常完成」,得靠 workflow 層的 outcome 補,http_status 不夠用。
以下範例把之前的 AskEvent 放進 handler。
函式名稱的實作由讀者接到自己的 retriever 與 model client;範例先展示事件應在哪一刻寫入。
from time import perf_counter
async def answer_policy_question(question: str) -> dict[str, object]:
event = new_ask_event()
event.timing.workflow_started = perf_counter()
event.technical_status = "running"
try:
event.timing.retrieval_started = perf_counter()
documents = await retrieve_policy_documents(question)
event.timing.retrieval_finished = perf_counter()
event.retrieval_count = len(documents)
if not documents:
event.outcome = "insufficient_context"
event.workflow_status = "completed"
event.quality_status = "not_evaluated"
event.safety_status = "not_applicable"
return {
"answer": "現有文件沒有足夠資訊可以回答這個問題。",
"request_id": event.request_id,
}
event.timing.model_started = perf_counter()
completion = await call_model(question=question, documents=documents)
event.timing.generation_finished = perf_counter()
parsed = parse_answer(completion)
validate_sources(parsed, documents)
event.outcome = "completed"
event.technical_status = "succeeded"
event.workflow_status = "completed"
event.quality_status = "not_evaluated"
event.safety_status = "not_evaluated"
return {
"answer": parsed.text,
"sources": parsed.source_ids,
"request_id": event.request_id,
}
except ProviderTimeoutError as error:
event.outcome = "technical_failed"
event.technical_status = "failed"
event.error_class = type(error).__name__
raise
finally:
event.timing.response_sent = perf_counter()
logger.info("ask_event", extra=event.to_log_record())
注意 finally。
無論 workflow 成功、資料不足或 provider timeout,事件都要有一個結尾。
否則你只會在 dashboard 看見成功請求,然後誤以為平均延遲很健康。
也注意 quality_status: "not_evaluated"。
它不等於 quality pass。
如果讀者接了第⑥節後半的 AskCost 擴充欄位,這個 finally 區塊也是它該掛進去的位置:response_sent 之後、logger.info 之前補上 event_cost = AskCost(prompt_tokens=completion.usage.prompt_tokens, ...)。放在 finally 而非 try 最後一行,理由跟 response_sent 一樣——即使 completion 因 provider timeout 從未賦值,也該老實記下「沒有 token 用量可回報」,而不是讓 finally 存取不存在的變數,拋出新例外蓋掉原本的錯誤。
Day 19 會處理 quality 與 task success;在那之前,保留未知值。
這段驗證了前面哪個論點:它是第⑥節整份 AskEvent 契約唯一一次被放進「看起來完整」的業務函式裡,直接證明第⑤節那張「區段對照表」不是紙上談兵——retrieval_started/retrieval_finished 包住 retrieval 區段,model_started/generation_finished 包住 generation 區段,中間若有 gap(例如 prompt 組裝耗時),會自然反映在可以事後算出來的空隙裡。
實際跑起來預期看到什麼:把 retrieve_policy_documents 換成回傳空 list 的假函式,應該看到 outcome: "insufficient_context",且 model_started、generation_finished、tool_finished 全部是 None——沒有文件就不該假裝呼叫過模型。把 call_model 換成拋出 ProviderTimeoutError 的假函式,應該看到 outcome: "technical_failed"、generation_finished 同樣是 None。兩種情況下 response_sent 都應該有值,這是 finally 保證的。
容易踩到的坑:最常見的錯誤是把 event.timing.xxx = perf_counter() 寫在 try 區塊「之後」而不是「之前」——例如呼叫 retrieve_policy_documents 之後才記 retrieval_started,量到的其實是「檢索做完、回頭補記」的時間,retrieval_ms 會被系統性低估。另一個錯誤是疊了多個 except 卻忘記每一支都設 event.outcome——AskEvent 預設 outcome: Outcome = "completed",忘記顯式改成 technical_failed 的失敗請求,最後會被 finally 記成 outcome: "completed",成為「看起來正常、實際上失敗了」的幽靈資料,正是「semantic failure 沒有錯誤訊號」在事件記錄層的翻版。
把上面兩種情境(provider timeout、正常完成)實際跑過 to_log_record(),log 裡應該分別看到類似下面兩筆結構完全一致、只有欄位值不同的記錄——這種「結構固定、值不同」正是事件契約存在的意義,值班工程師不用每次都重新理解一筆 log 的形狀:
{
"request_id": "req_8f2c1a3b9e4d4f6a",
"trace_id": "trace_8f2c1a3b9e4d4f6a",
"workflow_name": "policy_qa",
"workflow_version": "2026-09-18",
"model_name": "configured-at-deploy-time",
"outcome": "completed",
"technical_status": "succeeded",
"quality_status": "not_evaluated",
"retrieval_count": 3,
"queue_delay_ms": 12.4,
"retrieval_ms": 84.1,
"ttft_ms": null,
"generation_ms": 612.7,
"end_to_end_ms": 731.9
}
{
"request_id": "req_1d9a7c4e2b8f4a3c",
"trace_id": "trace_1d9a7c4e2b8f4a3c",
"workflow_name": "policy_qa",
"workflow_version": "2026-09-18",
"model_name": "configured-at-deploy-time",
"outcome": "technical_failed",
"technical_status": "failed",
"error_class": "ProviderTimeoutError",
"retrieval_count": 3,
"queue_delay_ms": 15.0,
"retrieval_ms": 91.3,
"ttft_ms": null,
"generation_ms": null,
"end_to_end_ms": 5108.6
}
第二筆記錄裡 generation_ms 是 null,end_to_end_ms 卻仍然有值——這正是 finally 保證的行為:即使 generation 沒有完成,response_sent 仍然被記錄下來(可能是逾時後回傳的錯誤訊息),端到端時間依然可以算出來,只是它不代表「回答完成的時間」,而是「這筆請求從頭到尾在系統裡待了多久」,兩者在失敗案例裡是不同的意義,這也是為什麼欄位命名不能只有一個籠統的 latency_ms。
串流回應不能只在 handler return 時才記第一個 token。
要在 iterator 第一次產生可見內容時落時間點。
下面是概念範例。
from collections.abc import AsyncIterator
from time import perf_counter
async def measured_token_stream(
upstream: AsyncIterator[str],
event: AskEvent,
) -> AsyncIterator[str]:
has_sent_first_token = False
async for token in upstream:
if token and not has_sent_first_token:
event.timing.first_token = perf_counter()
has_sent_first_token = True
yield token
event.timing.generation_finished = perf_counter()
真正接到 StreamingResponse 時,讀者還要確認兩件事。
第一,第一個上游 callback 不一定等於第一個 client 可見 token;中間可能有 buffer、proxy 或輸出格式轉換。
第二,yield dependency 的 teardown 在 streaming response 結束後才執行,長時間 stream 可能意外持有 database connection 或其他資源。FastAPI Streaming Response 文件
因此要在服務端將 TTFT 命名成例如 server_first_token_ms,不要假裝它是瀏覽器端已渲染時間。
若產品真的要量使用者端感受,可再由前端上報 first_rendered_token_at;兩種資料應用同一個 request_id 關聯,而不是硬塞在同一個 timer。
這段驗證了前面哪個論點:它直接對應第⑤節「三個延遲數字,回答三個不同問題」與 Dan Luu 那段「server 端量到的不是使用者感受到的」——measured_token_stream 刻意只在「上游 iterator 第一次產生內容」時打點,而不是在「client 收到」時打點,正是要讓讀者看見 server_first_token_ms 這個命名裡「server」兩個字不是多餘的贅字,而是誠實標註量測邊界的必要提醒。
實際跑起來預期看到什麼:把 upstream 換成一個 async def fake_stream(): yield "a"; await asyncio.sleep(0.1); yield "b" 這樣的假 generator,event.timing.first_token 應該在第一次 yield "a" 附近落下,且與 event.timing.model_started 的差值接近 0(因為假 generator 幾乎沒有延遲);如果在 fake_stream 開頭先 await asyncio.sleep(0.5) 再 yield,這個 0.5 秒應該完整反映在 ttft_ms 裡,而不是被吃掉。
容易踩到的坑:第一,若上游 SDK 拿到完整回應後才切成假的「串流」回傳,first_token 量到的其實是「整個生成都做完後,第一次切片」的時間,TTFT 會跟 generation_finished 幾乎相等——這不是計時邏輯錯了,要往上查 provider client 有沒有做真正的 token-level streaming。第二,若 async for token in upstream 被包在 asyncio.wait_for() 整體逾時裡,逾時觸發時 generation_finished 不會被寫到——與其編造一個看似合理的結束時間,不如老實留 None。
看到前面的 event schema,很自然會想把每個欄位做成 label。
請先停一下。
下面是高 cardinality 的反例:
ask_request_duration_seconds{
request_id="req_42...",
prompt="使用者完整問題...",
model_response="模型完整回答...",
trace_id="trace_42..."
}
這會讓每一筆 request 都變成一條新的 time series。
原因在 Prometheus 的資料模型:一條 time series 由 metric 名稱加上全部 label 的組合唯一決定。label 值一旦帶進高基數欄位(像 request_id 這種每筆都不同的值),每一筆 request 產生的不是「同一條 series 多一筆資料點」,而是「一條全新的 series」。series 數量直接決定 Prometheus 要在記憶體裡維護多少 head chunk、一次查詢要掃描多少序列;series 數量從數千長到數百萬,通常不是平滑地變慢,而是某個時間點記憶體用量與查詢延遲一起跳增。這種跳增又特別容易撞上 incident,因為 incident 期間 request 量與 request_id 種類本來就在暴增,你需要 Prometheus 反應最快的時候,恰好是它最吃緊的時候。
Prometheus 不需要承受這種痛苦,你也不需要在 incident 時等它慢慢回敬你。
這裡值得多解釋一句「為什麼不是平滑變慢」。Prometheus 把每條 active time series 的最近資料點都保存在記憶體裡的 head chunk,series 數量增加就要維護更多 head chunk;只要記憶體還夠用,延遲增幅通常還算溫和。但記憶體是硬上限,一旦逼近上限,作業系統開始頻繁換頁,延遲曲線就從緩升轉成幾乎垂直的跳增——這正是為什麼高 cardinality 問題經常被描述成「昨天都好好的,今天流量一多整個炸掉」,而不是提早幾天就看得到警訊的漸進式劣化。
常見的誤解是以為「只要不對 request_id 這種完全唯一的欄位打 label 就安全」。其實危險的不是單一欄位的基數,而是所有 label 值組合起來的基數:route 20 種、model_route 5 種、outcome 5 種、workflow_version 若一天發布 10 次、保留 30 天有 300 種——相乘後一條 metric 可能瞬間衍生出 20 × 5 × 5 × 300 = 150,000 條 time series,而沒有任何一個欄位單獨看起來「高基數」。這正是為什麼 workflow_version 每個 commit 都不同,仍可能讓 cardinality 持續升高——不是它本身邪惡,是它會和其他維度做乘法。
較穩定的 metrics 維度可以是:
route
workflow_name
workflow_version
response_mode
outcome
model_route
fallback_triggered
即使是這些 label 也要節制。
workflow_version 若每個 commit 都不同,仍可能讓 cardinality 持續升高。
可考慮只保留 release version,完整 commit SHA 留在 log 與 trace。
| 資料 | 最適合的位置 | 原因 |
|---|---|---|
| request_id、trace_id | log / trace | 每次請求都不同 |
| prompt、response、retrieved documents | 受權限控管的 trace / audit store | 可能敏感且量大 |
outcome、response_mode |
metric label | 值域小,可聚合 |
| TTFT、queue delay、e2e duration | histogram / trace | 可看分布與單筆路徑 |
| model route、fallback | metric label 或 resource attribute | 值域需受控 |
| token 數、cost estimate | counter / histogram / trace | 需明確單位與抽樣策略 |
這個分層也讓 incident 調查有合理入口:先用 metrics 找到何時與哪種 outcome 變壞,再以 trace 和 log 找到單一失敗路徑。
不是反過來在億萬個 log 內搜尋「慢」。
把上面的分層原則寫成程式碼,大概會長這樣。這裡用 prometheus-client 示範一個 histogram,只帶低基數 label;高基數欄位則走 to_log_record() 產生的 structured log,兩者用 request_id 事後關聯。
from prometheus_client import Histogram
ASK_REQUEST_DURATION = Histogram(
"ask_request_duration_seconds",
"End-to-end duration for /ask requests",
labelnames=["route", "outcome", "response_mode"],
buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30),
)
def record_metrics(record: dict[str, object]) -> None:
end_to_end_ms = record.get("end_to_end_ms")
if end_to_end_ms is None:
return # 沒有量到就不上報,不要用 0 假裝完成
ASK_REQUEST_DURATION.labels(
route="/ask",
outcome=record["outcome"],
response_mode=record.get("response_mode", "unknown"),
).observe(end_to_end_ms / 1000)
留意 bucket 邊界的選法:這裡刻意在 1 秒與 2 秒之間放了切點,因為第⑤節的對照表顯示聊天介面的 TTFT 門檻常落在這個區間;若只設 (0.5, 5, 50) 這種粗略切法,「1 秒內有沒有開始講話」會被壓進過寬的桶子裡看不出細節。bucket 邊界跟 cardinality 是同一種取捨的兩面:label 太細讓 series 爆炸,bucket 太粗讓分布資訊丟失,都要刻意選,不能用預設值打發。
在接 Prometheus、OpenTelemetry 或任何 vendor 之前,先讓事件契約通過幾個人工可判讀的案例。
以下表格可以直接變成 DIY 的 fixture 清單。
| 情境 | 注入方式 | 預期事件 | 不該發生的誤判 |
|---|---|---|---|
| 正常串流 | model 逐 token 回傳 | first_token 與 generation_finished 都有值 |
TTFT 被記成整段 generation |
| queue 壅塞 | 暫停 worker 或提高併發 | queue_delay_ms 上升 |
被算成 model latency |
| 無文件可答 | retrieval 回空陣列 | insufficient_context,非 technical failure |
當成 API 500 |
| provider timeout | model client 到時失敗 | technical_failed 與 error_class |
在 finally 前沒有事件 |
| parser 壞掉 | 回傳不合契約輸出 | technical/workflow status 可診斷 | HTTP 200 被列為 completed |
| safety 拒絕 | policy engine 拒絕 tool | safety_blocked |
被當成 model timeout |
| retrieval 灌入過量文件 | fake retriever 回傳 20 篇文件而非平常的 2-3 篇 | prompt_tokens 明顯升高、outcome 仍是 completed |
latency 正常就當作沒事,cost 不會被順便檢查到 |
這些 fixture 不一定都要真的打到外部模型。
一開始用 fake retriever、fake token stream 和明確 timeout 即可。
先確認事件語意,再接真實 dependency;否則 provider 剛好正常時,你只能驗證 happy path。
上面那張表格若只停在文件層次,遇到 incident 時仍然只能憑印象猜「當時是不是這種情況」。比較踏實的做法,是把每一列都寫成一個 pytest fixture,讓「provider timeout 時事件契約長什麼樣子」變成一行指令就能重跑的問題,而不是每次 incident 都要重新在腦中模擬一次。
import pytest
class FakeRetriever:
def __init__(self, documents: list[str] | None = None) -> None:
self._documents = documents or []
async def __call__(self, question: str) -> list[str]:
return self._documents
class FakeModelClient:
def __init__(self, *, delay: float = 0, raises: Exception | None = None) -> None:
self._delay = delay
self._raises = raises
async def __call__(self, **kwargs: object) -> str:
await asyncio.sleep(self._delay)
if self._raises is not None:
raise self._raises
return "這是一段假的模型回答。"
@pytest.fixture
def no_context_scenario() -> FakeRetriever:
return FakeRetriever(documents=[])
@pytest.fixture
def provider_timeout_scenario() -> FakeModelClient:
return FakeModelClient(raises=ProviderTimeoutError("provider did not respond in time"))
@pytest.fixture
def slow_generation_scenario() -> FakeModelClient:
return FakeModelClient(delay=3.0)
有了這些 fixture,表格裡的七種情境可以各自對應一個測試函式,斷言 event.outcome、event.timing 裡哪些欄位有值、哪些該維持 None。這批測試不需要連上任何外部服務,跑起來是秒級的,可以放進 CI pipeline,每次改動 handler 邏輯都自動驗證情境還是被正確分類——這是「先確認事件語意,再接真實 dependency」在工程實踐上的具體樣子。
讀者可為事件契約寫一個小測試。
def test_missing_first_token_stays_unknown() -> None:
event = new_ask_event()
event.timing.workflow_started = event.timing.request_received + 0.2
event.timing.response_sent = event.timing.request_received + 1.2
record = event.to_log_record()
assert record["queue_delay_ms"] == 200.0
assert record["ttft_ms"] is None
assert record["end_to_end_ms"] == 1200.0
這個測試的價值很小,卻能防止一個常見回歸:有人為了讓 dashboard 少出現空值,把 None 改成 0。
測試通過只能證明事件轉換函式符合這個 fixture。
它不能證明 production 的 TTFT 被正確量到,也不能證明網路端沒有 buffering。
這個證據邊界要寫清楚。
┌─────────────────────┐
│ production 真實流量 │ ← 只有這一層能證明「使用者實際感受到什麼」
│ (尚未在本文驗證) │
├─────────────────────┤
│ staging 端對端重放 │ ← 接真的 retrieval / model,但流量是重放的
│ (尚未在本文驗證) │
├─────────────────────┤
│ fixture 情境測試 │ ← 本節做的事:FakeRetriever/FakeModelClient
│ (六種情境,秒級) │
├─────────────────────┤
│ 單元測試(契約邏輯) │ ← to_log_record()、duration 計算
└─────────────────────┘
金字塔最底層跑得快、成本低,但只能驗證「程式邏輯有沒有照契約定義的規則走」,證明不了 retrieval 服務真的在 84ms 內回應。往上一層的 staging 重放接近真實 dependency,但流量是人為重放的;只有最上層的 production 真實流量,才會出現 fixture 沒想到的組合,例如某個使用者剛好在 retrieval 慢的同時觸發了 provider timeout。越往上越貼近真實,也越貴、越難重現失敗。本文與對應的 DIY 只做到底下兩層,是刻意的取捨——先把契約定義對,比急著接 production 流量更重要。
LLM workflow 的每個訊號都可以畫圖,不代表每個訊號都該叫醒人。
建議先分三類。
| 類型 | 範例 | 建議處理 |
|---|---|---|
| 立即影響使用者的技術故障 | 5xx、provider timeout、queue 無法消化 | 依 SLO / burn rate 告警 |
| 需要 investigation 的品質訊號 | citation-valid ratio 降低、insufficient_context 上升 |
建立 ticket、抽樣評估、比對資料變更 |
| 容量與成本訊號 | token throughput 下滑、token 使用增加 | 容量規劃、route 或 prompt 檢討 |
insufficient_context 比例升高是一個好例子。
它可能代表索引壞了,也可能代表公司剛更新政策、使用者開始問一批新問題,而系統誠實地說不知道。
直接把它當 outage 會製造噪音。
完全忽略它又會錯過 retriever 失效。
比較好的第一步是:和 deploy、retrieval index version、文件更新時間以及 query category 一起看。
Google SRE 對監控的提醒仍然適用:監控必須能帶來可採取的行動,而不是收集所有可收集的資料。Google SRE Workbook:Monitoring
前面把訊號分成三類,但「立即影響使用者的技術故障」這一類要怎麼決定告警門檻,值得展開一點。單純看瞬間數字(例如「5xx 比例超過 5%」)容易有兩種失衡:門檻設太敏感,尖峰流量下的正常抖動也會觸發;門檻設太保守,真正的故障要拖很久才會被發現。
比較穩健的做法,是不看瞬間值,而看「錯誤預算消耗的速度」——這個概念 Day 20 會正式展開,這裡先用簡化版本說明。假設目標是 99.5%(一個月的錯誤預算是 0.5%),錯誤率瞬間衝到 20%,消耗速度是正常速度的 40 倍,整月預算會在不到一小時內燒光,值得立刻叫醒人。但若錯誤率只從 0.3% 微幅升到 0.6%,燒錢速度只比正常快一點,可能拖上好幾天才燒光,更適合開一張 ticket、白天處理。
消耗速度 = 目前錯誤率 / SLO 允許的錯誤率
消耗速度 40x → 一小時內燒光整月預算 → page
消耗速度 2x → 半個月燒光整月預算 → 開 ticket,上班時間處理
消耗速度 0.8x → 預算還在累積 → 不用管
這個思路對 AI SLI 一樣適用,只是要換算對象:把「錯誤率」換成「insufficient_context 比例」或「safety block 比例」,一樣可以問「照這個速度,這個月的品質預算什麼時候會燒光」,而不是只用一條瞬間門檻線來決定該不該緊張。差別在於,technical 故障通常有明確、可驗證的「壞」的定義(5xx 就是壞),品質類訊號則經常需要人工抽樣確認「這批上升是不是真的壞」,所以消耗速度算出來即使很快,也建議先進 investigation,而非自動 page——這正是為什麼前面的分類表把兩者的建議處理方式寫得不一樣。
以下不是建議直接抄到 production 的門檻。
它是讓團隊開始討論分子、分母與例外的草案。
Service: policy Q&A /ask
Window: rolling 28 days
Technical availability SLI
numerator: requests with a completed HTTP response and no technical_failed outcome
denominator: eligible /ask requests, excluding caller-cancelled requests by documented rule
Interactive responsiveness SLI
numerator: streaming requests whose server_first_token_ms <= agreed threshold
denominator: eligible streaming /ask requests
Workflow completion diagnostic
numerator: requests with workflow_status=completed
denominator: eligible /ask requests
此處刻意沒有填一個看起來很專業的百分比或毫秒數。
沒有 traffic baseline、使用者任務與成本資料之前,任何精確門檻都只是猜測。
Day 20 會把 error budget 放進這條線;屆時才討論「違反多少才要停止變更」會比較合理。
上面那份草案刻意留白,沒有填任何具體百分比,容易讓人懷疑「這種把 SLO 套進 AI 品質訊號的做法,是不是本系列自己想出來、業界根本沒人這樣做」。剛好在本文擴寫的同一個月,Grafana Labs 發了一篇文章直接示範了同一套換算,值得拿來對照。Grafana Labs:What if your agent's hallucinations had a budget?
那篇文章把 agent 的行為拆成好幾個各自獨立量測的面向——groundedness、fulfillment(有沒有真的完成使用者要求的任務)、toxicity、information leakage、prompt injection resistance,外加 latency 與 cost。跟本文②段的七個 AI SLI 訊號不是同一組欄位,這是預期中的事:本文從「一次 /ask 的時間軸與工具呼叫」切入,Grafana 那篇從「一段對話整體的行為品質」切入,顆粒度不同,卻在做同一件事——把「品質」拆成可以獨立量測、獨立設門檻的訊號,而不是壓成一個籠統的「好不好」。
真正值得引用的,是文章描述語意失敗的那句話,幾乎是本文②段「技術成功不保證語意成功」的逐字迴響:一次回答可以在「800 毫秒內完成、幾乎不花任何 token 成本、拋出零個 error」,同時卻「自信滿滿、文從字順、但完全講錯」。快、便宜、沒有錯誤訊號——三個都綠燈,答案還是可能是錯的。這不是巧合式的用詞雷同,而是兩篇文章從各自的方向撞進同一個事實:只要故障不透過 exception 或高延遲現身,傳統監控就註定看不到它。
文章示範的儀表板,是一個 30 天滾動窗口的 helpfulness SLO,目標訂在 95%,剩下 5% 是這個面向的 error budget。這個數字不是重點,跟本文一樣只是「先讓團隊有東西可以吵」的起點。真正值得借用的,是文章對 error budget 的另一種用法:budget 還沒燒完時,鼓勵團隊拿它去做更大膽的嘗試——換更激進的 prompt、換新模型、放寬某個 guardrail;budget 快燒光時才收斂節奏。這跟 Day 20 要談的傳統 error budget 邏輯(燒光就凍結變更)方向一致,但多了一層「budget 是許可證,不只是煞車皮」的框架,跟前面「消耗速度」的算式互補而非重複。
這篇文章也剛好呼應 Day 19 預告的伏筆:它用 evaluator(另一個 LLM 或 regex 規則)替每輪對話打分,而 evaluator 本身判斷得準不準,是 Day 19 要正面處理的問題——SLO 框架量到的,可能是「agent 表現得好不好」,也可能只是「evaluator 覺得好不好」,兩者不永遠一致。
想像 dashboard 在 10:20 開始顯示:end-to-end P95 從 4 秒升到 18 秒。
不要立刻對模型下結論。
照下面順序縮小範圍。
1. 受影響的是全部 route,還是只有 /ask?
2. queue_delay_ms 是否先上升?
3. retrieval_ms、provider wait、generation_ms,哪一段真的變慢?
4. timeout / retry / fallback 是否同時增加?
5. workflow_version、model route、index version 是否剛好變更?
6. technical failure 與 quality signal 是否一起變?
這通常先查 worker concurrency、queue depth、rate limit 與 admission control。
把更多 timeout 加到 provider client,無法讓排隊中的工作自己跑完。
若 worker 已飽和,retry 甚至可能讓 queue 更長。
先比對 index version、資料庫連線池、embedding dependency 與 query latency。
也要看 retrieval count 是否突然變大。
有時候不是搜尋系統壞了,而是新的 prompt 讓每題都要求更多 context,結果把後面的 model input 一起拖慢。
這代表使用者很快看到開始輸出,慢的區段可能在 generation 長度、tool 或後處理。
查看 output token 分布與 tool count。
如果 response 已經開始 streaming,還要釐清 tool 是否真的在使用者可見答案之後才跑;不同 workflow 的正確設計不同,不能只從一個長尾判斷 bug。
quality_failed 增加這不是 latency incident,卻可能更嚴重。
回到 Day 7:技術成功不是語意成功。
此時應保存可安全調查的 trace、版本與 evaluator 結果,將代表案例加入 dataset,再決定 rollback prompt、index 或 model route。
不要為了把 error rate 維持好看而把它藏進 completed。
真實世界很少乾脆地照著判讀 A 到 D 分類,OpenAI 在 2024 年 12 月 4 日的事故是一個好對照。OpenAI 官方事後報告
當天發生兩波故障。第一波是全域 load balancer 設定錯誤,四分鐘內 100% 的 API 請求收到 HTTP 530,訊號很明確,幾乎不需要判讀。
第二波比較難查。一次 DNS cache 系統升級,觸發了身分驗證(identity)系統與 DNS cache 之間一個原本沒被記錄、也不必要的隱藏依賴,讓需要驗證身分的請求開始卡住,最長延遲到 30 秒,45% 的 API 請求收到 499(client 端等不下去先斷線),持續了 90 分鐘。
從使用者角度看,這 90 分鐘的體感就是「AI 突然變得異常慢」。若只照判讀 A 到 D 的直覺排查,工程師很容易先查 model provider——但真正故障點在鏈路最前面,離 retrieval、generation 都很遠的身分驗證與 DNS 這一層。
判讀線索有兩個:故障只影響需要身分驗證的請求而非隨機分布,代表問題在 admission 這段;45% 的 499 與 30 秒延遲同時出現,代表 client 端主動放棄連線,不是被正常排隊擋住——這正是「timeout / retry 是否同時增加」要問的問題。
這也印證事件契約要把 admission、queue_delay_ms、provider_wait 分開記錄的價值:分開記錄才有機會在第一輪就被指到正確方向;若都算進同一個 latency_ms,很容易被誤診成「provider 變慢」,在錯的地方查大半天。
還有一種情況跟前面五種性質不太一樣:dashboard 上的平均 latency、甚至 P50 都風平浪靜,只有 P99 明顯拉高,且集中在 retrieval_ms 或 tool_ms。這值得回頭想第⑤節扇出的討論——若 retrieval 平行查詢多個 shard、tool 平行呼叫多個 API,《The Tail at Scale》給過數學解釋:平行數量夠多,「等全部回來」踩到慢 leaf 的機率會被系統性放大,而這個放大效應天生只出現在尾端百分位,不會反映在平均值或 P50。
判讀第一步,是問「扇出的 leaf 數量最近有沒有變多」——向量索引是否增加了 shard、workflow 呼叫的 tool 數量是否變多。若 leaf 數量沒變,才照一般邏輯查某個特定 leaf 是否本身變慢。分辨這兩種情況很重要:前者是架構層面的取捨,後者才是單點故障,適合直接找到變慢的元件修。
六種判讀分別回答不同的問題,實際 on-call 時很難記得先問哪一題。下面這張圖把它們串成一條可以照著走的路徑,起點永遠是那六個排查步驟裡的第一步:
end-to-end P95 突然升高
│
┌─────────────┴─────────────┐
│ 只有 /ask 慢,其他 route 正常? │
└─────────────┬─────────────┘
是│ │否 → 判讀 E(故障點離 AI 最遠)
▼
queue_delay_ms 是否先上升?
是│ │否
▼ ▼
判讀 A(worker/ retrieval_ms 是否變慢?
admission 層) 是│ │否
▼ ▼
判讀 B(index/ TTFT 是否也正常?
embedding 層) 是│ │否
▼ ▼
判讀 C(generation/ 判讀 F(只有
tool/後處理層) P99 在飆,查
扇出 leaf 數)
另一條並行檢查:
HTTP 狀態正常,quality_failed 增加?→ 判讀 D(語意層,不是 latency incident)
這張圖不是要取代判斷力,六個判讀底下實際排查時仍然要看版本變更、依賴狀態這些細節;它的作用是縮小「一開始該往哪裡看」的猜測範圍,把原本可能同時查三四個方向的 on-call,收斂成一條可以循序驗證的路徑。
上面六種判讀有一個共同的隱藏假設:症狀是持續的——P95 持續偏高,或 quality_failed 持續增加,只要盯著同一張圖,趨勢不會自己反覆橫跳。但真實世界還有一種更難纏的症狀形狀:系統每隔幾分鐘自己好一次、又壞一次,這種週期性起伏本身就會誤導判讀方向,把工程師帶去查一個完全錯誤的假設。Cloudflare 在 2025 年 11 月 18 日的一次全球性中斷,是這種症狀形狀的乾淨案例。Cloudflare 官方事後報告
故障起點離「AI」和「latency」都很遠:一次資料庫權限變更,把 ClickHouse 對某些 system table 原本隱性允許的存取,改成需要顯性授權。這個變更立意良善(安全性強化),卻讓一段產生 Bot Management「feature file」的內部查詢,開始從 default 與 r0 兩個 database 同時拿回重複欄位——檔案筆數從平常約 120 筆暴增到超過 200 筆。這段查詢原本悄悄假設「metadata 回應裡只有一種 schema」,這個假設在權限變更後被打破,系統其他部分完全不知情。
權限變更(fault)
→ 查詢回傳重複欄位,檔案變成 200+ 筆(error,此時對外仍正常)
→ Bot Management 硬編碼 200 個 feature 的記憶體上限被超過
→ Rust 程式碼 panic:unwrap() on an Err value
→ 核心 proxy(FL2)大量回傳 HTTP 5xx(failure,使用者開始有感)
這條鏈路正是本文一路強調的「Fault → Error → Failure」——多拿了幾欄資料的當下,系統還沒有任何對外可見異常,要等到硬編碼上限被踩過、panic 被觸發,才變成使用者看得到的東西。真正讓事故難查的是接下來的事:產生設定檔的 ClickHouse 節點分批更新,好壞檔案交替被拉取,服務每隔約五分鐘自己恢復一次、又壞一次,這種規律讓工程師一度懷疑是 DDoS(同時 Cloudflare 自家 status page 也碰巧掛掉,進一步誤導判斷),直到方向轉回「回滾 Bot Management 設定檔」才真正解決——從故障發生算起,將近六個小時。
| 時間(UTC) | 事件 |
|---|---|
| 11:05 | 資料庫權限變更部署 |
| 11:20 | 大量網路故障開始,工程師一度懷疑是 DDoS |
| 13:37 | 團隊確認方向:回滾 Bot Management 設定檔 |
| 14:30 | 舊版 feature file 全網部署完成,主要影響解除 |
| 17:06 | 所有服務完全恢復 |
放回本文的判讀框架,這個案例帶來兩個教訓。第一,「症狀有沒有週期性起伏」值得先問——dashboard 上的 5xx 比例若呈規律鋸齒狀而非單調上升,暗示「某個東西正在分批推播、分批生效」,該優先查最近的分階段 rollout,而不是先假設外部攻擊。第二,「系統自己覺得好轉」不代表故障已解除——這呼應本文①段「基礎設施健康 ≠ 使用者體驗健康」的第三種樣貌:連「系統覺得自己健康」這個訊號本身都在騙人,短暫的恢復窗口反而把排查方向帶偏。
Cloudflare 自己的結論是:「要像對待使用者輸入一樣,強化對自己產生的設定檔的驗證」——真正的根因不是查詢寫錯,是內部系統產生的資料悄悄變了形狀,卻沒有 schema 層的檢查攔下來。這跟本文⑥段「事件契約要有版本、變更要能追溯」是同一種疾病的兩種病灶:一個發生在可觀測性資料,一個發生在功能設定資料,共同病理都是「把內部產生的資料當成永遠可信」。事後報告另提到「開放更多 global kill switch」,也呼應判讀 A:模組沒有獨立的緊急關閉開關,故障只能靠回滾整條發布管線止血,處理時間自然被拉長。
本文的實作段落由讀者自行執行;下列清單是可重跑的驗收條件,不是本文已完成的實測聲明。
/ask request 都會產生可關聯的 request_id。workflow_version、model 與 retrieval index 的版本資訊。request_received、workflow_started、retrieval_finished 與 response_sent 的定義已寫在程式碼或 README。null 或明確模式,而不是 0。technical_failed、insufficient_context、quality_failed 與 safety_blocked 沒有被塞進同一個 success label。/metrics 變慢才回頭查。最後五項是這兩輪擴寫新加的,理由很直接:前九項驗證的是「事件契約有沒有定義好」,但契約定義好之後,還有事情會在生產環境咬人——契約會改版而沒人追蹤是哪個版本在跑,label 組合會在沒人注意的時候悄悄超過記憶體預算,扇出架構會在個別元件都健康的狀況下讓 P99 自己長出一條原本不存在的尾巴,內部系統自己產生的資料(設定檔、索引檔案)一旦悄悄變了形狀,也可能像 Cloudflare 2025 年 11 月的那次事故一樣繞過所有「只驗證外部輸入」的防線直接炸穿系統,而 token 用量與成本這個維度,則可能在前面每一項都打勾的情況下獨自失控。這五項不是「做完事件契約就自動满足」的副產品,是需要額外檢查的獨立條件。
一個「LLM 很慢」的 ticket,可能是 queue、retrieval、provider、輸出長度、tool、proxy buffer,也可能根本不是 latency 問題,而是錯答被使用者重新問了三次。
把 request 拆成可定義的事件,沒有魔法地讓系統變快。
它讓下一次變慢時,團隊不必從一張平均 latency 圖開始猜。
這也是為什麼本文花不少篇幅講 Dean 與 Barroso 那篇論文——扇出架構下,個別元件平均延遲再正常,只要並行呼叫數量夠多,「至少一個慢」發生的機率就會被放大到接近必然。這不會因為多加幾個 metrics 而自動現形;只有先把 retrieval、tool 這些扇出點的時間戳跟其他階段分開記錄,才有機會在 dashboard 上看到那條被平均值蓋住的尾巴。這正是①到⑫節一路在建立的事:不是多裝儀表,是讓每個時間點各自對應一個可指認、可歸責的系統元件。
Day 19 會跨過更麻煩的一條線:quality、safety 與 task success 要怎麼量,才不會把 evaluator 分數當成真相。
這次擴寫加進來的幾件事,其實都指向同一個提醒。
Cloudflare 那次事故提醒的是:連「系統覺得自己健康」這個訊號本身,都可能是假的——週期性的短暫恢復,把工程師的判斷帶往完全錯誤的方向長達數小時。
Grafana Labs 那篇文章提醒的是:本文談的這套「把品質拆成獨立訊號、套上 error budget」的做法,不是本系列自己想像出來的骨架,業界正在往同一個方向收斂。
AskCost 提醒的是:latency 綠燈、outcome 是 completed,不代表這次回答便宜——成本可以在所有其他訊號都正常的情況下,自己悄悄超支。
tool call 的語意驗證與 safety block 的分級提醒的是:一個看似乾淨的枚舉值(outcome、safety_blocked)底下,經常藏著好幾種需要不同處理速度的情境,攤平成單一狀態,反而會讓真正急迫的那一種被稀釋掉。
這幾件事表面上分散在不同段落,實際上都是同一個核心立場的不同案例:任何一個看起來「正常」的訊號,都值得先問一句「正常」量的到底是什麼、又量不到什麼。這正是本文標題那句「把慢拆到能修的位置」真正想強調的事——拆解的目的不是產生更多圖表,是讓每一次「正常」的宣告,背後都有一個明確的、可以被戳破的定義。
Day 18 把一次 AI request 的等待與失敗拆成可追查的事件;下一篇會把「回答有沒有真的幫使用者完成事情」變成可抽樣、可回歸、但不假裝絕對客觀的 quality 與 safety 訊號。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.